# ORM 방식으로 데이터 조작하기

이전 챕터까지 CORE 관점에서 쿼리를 활용하는 방식에 초점을 맞췄습니다. 이번 챕터에서는 ORM 방식에서 쓰이는 Session의 구성 요소와 수명 주기, 상호 작용하는 방법을 설명합니다.


# ORM으로 행 삽입하기

Session 객체는 ORM을 사용할 때 Insert 객체들을 만들고 트랜잭션에서 이 객체들을 내보내는 역할을 합니다. Session은 이러한 과정들을 수행하기 위해 객체 항목을 추가합니다. 그 후 flush라는 프로세스를 통해 새로운 항목들을 데이터베이스에 기록합니다.

# 행을 나타내는 객체의 인스턴스

이전 과정에서 우리는 Python Dict를 사용하여 INSERT를 실행하였습니다.

ORM에서는 테이블 메타데이터 정의에서 정의한 사용자 정의 Python 객체를 직접 사용합니다.

>>> squidward = User(name="squidward", fullname="Squidward Tentacles")
>>> krabs = User(name="ehkrabs", fullname="Eugene H. Krabs")

INSERT 될 잠재적인 데이터베이스 행을 나타내는 두 개의 User 객체를 만듭니다. ORM 매핑에 의해 자동으로 생성된 __init__() 생성자 덕에 생성자의 열 이름을 키로 사용하여 각 객체를 생성할 수 있습니다.

>>> squidward
User(id=None, name='squidward', fullname='Squidward Tentacles')

Core의 Insert와 유사하게, 기본 키를 포함하지 않아도 ORM이 이를 통합시켜 줍니다. idNone 값은 속성에 아직 값이 없음을 나타내기 위해 SQLAlchemy에서 제공합니다.

현재 위의 두 객체(squiwardkrabs)는 transient 상태라고 불리게 됩니다. transient 상태란, 어떤 데이터베이스와 연결되지 않고, INSERT문을 생성할 수 있는 Session객체와도 아직 연결되지 않은 상태를 의미합니다.

# Session에 객체 추가하기

>>> session = Session(engine) # 반드시 사용 후 close 해야 합니다.
>>> session.add(squidward) # Session.add() 매소드를 통해서 객체를 Session에 추가해줍니다.
>>> session.add(krabs)

객체가 Session.add()를 통해서 Session에 추가하게 되면, pending 상태가 되었다고 부릅니다. pending 상태는 아직 데이터베이스에 추가되지 않은 상태입니다.

>>> session.new # session.new를 통해서 pending 상태에 있는 객체들을 확인할 수 있습니다.
IdentitySet([User(id=None, name='squidward', fullname='Squidward Tentacles'), User(id=None, name='ehkrabs', fullname='Eugene H. Krabs')])
  • IdentitySet은 모든 경우에 객체 ID를 hash하는 Python set입니다.
  • 즉, Python 내장 함수 중 hash()가 아닌, id() 메소드를 사용하고 있습니다.

# Flushing

Session 객체는 unit of work 패턴 (opens new window)을 사용합니다. 이는 변경 사항을 누적하지만, 필요할 때까지는 실제로 데이터베이스와 통신을 하지 않음을 의미합니다. 이런 동작 방식을 통해서 위에서 언급한 pending 상태의 객체들이 더 효율적인 SQL DML로 사용됩니다. 현재의 변경된 사항들을 실제로 Database에 SQL을 통해 내보내는 작업을 flush 이라고 합니다.

>>> session.flush()
"""
INSERT INTO user_account (name, fullname) VALUES (?, ?)
[...] ('squidward', 'Squidward Tentacles')
INSERT INTO user_account (name, fullname) VALUES (?, ?)
[...] ('ehkrabs', 'Eugene H. Krabs')
"""

이제 트랜잭션은 Session.commit(), Session.rollback(), Session.close() 중 하나가 호출될 때 까지 열린 상태로 유지됩니다.

Session.flush()를 직접 사용하여, 현재 pending 상태에 있는 내용을 직접 밀어넣을 수 있지만, Session은 autoflush라는 동작을 특징으로 하므로 일반적으로는 필요하지 않습니다. Session.commit()이 호출 될 때 마다 변경 사항을 flush 합니다.

# 자동 생성된 기본 키 속성

행이 삽입되게 되면, 우리가 생성한 Python 객체는 persistent 라는 상태가 됩니다. persistent 상태는 로드된 Session 객체와 연결됩니다.

INSERT 실행 시, ORM이 각각의 새 객체에 대한 기본 키 식별자를 검색하는 효과를 가져옵니다. 이전에 소개한것과 동일한 CursorResult.inserted_primary_key 접근자를 사용합니다.

>>> squidward.id
4
>>> krabs.id
5

ORM이 flush 될 때, executemany 대신, 두 개의 다른 INSERT 문을 사용하는 이유가 바로 이 CursorResult.inserted_primary_key 때문입니다.

  • SQLite의 경우 한 번에 한 열을 INSERT 해야 자동 증가 기능을 사용할 수 있습니다.(PostgreSQL의 IDENTITY나 SERIAL 기능등 다른 다양한 데이터베이스들의 경우들도 이처럼 동작합니다.)
  • psycopg2와 같이 한번에 많은 데이터에 대한 기본 키 정보를 제공 받을 수 있는 데이터베이스가 연결되어 있다면, ORM은 이를 최적화하여 많은 열을 한번에 INSERT 하도록 합니다.

# Identity Map

Identity Map(ID Map)은 현재 메모리에 로드된 모든 객체를 기본 키 ID에 연결하는 메모리 내 저장소입니다. Session.get()을 통해서 객체 중 하나를 검색할 수 있습니다. 이 메소드는 객체가 메모리에 있으면, ID Map에서, 그렇지 않으면 SELECT문을 통해서 객체를 검색합니다.

>>> some_squidward = session.get(User, 4)
>>> some_squidward
User(id=4, name='squidward', fullname='Squidward Tentacles')

중요한 점은, ID Map은 Python 객체 중에서도 고유한 객체를 유지하고 있다는 점입니다.

>>> some_squidward is squidward 
True

ID map은 동기화되지 않은 상태에서, 트랜잭션 내에서 복잡한 개체 집합을 조작할 수 있도록 하는 중요한 기능입니다.

# Committing

현재까지의 변경사항을 트랜잭션에 commit 합니다.

>>> session.commit()
COMMIT

# ORM 객체 UPDATE하기

ORM을 통해 UPDATE 하는 방법에는 2가지 방법이 있습니다.

  1. Session에서 사용하는 unit of work 패턴 방식이 있습니다. 변경사항이 있는 기본 키 별로 UPDATE 작업이 순서대로 내보내지게 됩니다.
  2. "ORM 사용 업데이트"라고 하며 명시적으로 Session과 함께 Update 구성을 사용할 수도 있습니다.

# 변경사항을 자동으로 업데이트하기

>>> sandy = session.execute(select(User).filter_by(name="sandy")).scalar_one()
"""
SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account
WHERE user_account.name = ?
[...] ('sandy',)
"""

이 'Sandy' 유저 객체는 데이터베이스에서 행, 더 구체적으로는 트랙잭션 측면에서 기본 키가 2인 행에 대한 proxy 역할을 합니다.

>>> sandy
User(id=2, name='sandy', fullname='Sandy Cheeks')
>>> sandy.fullname = "Sandy Squirrel" # 객체의 속성을 변화시키면, Session은 이 변화를 기록합니다.
>>> sandy in session.dirty # 이렇게 변한 객체는 dirty 라고 불리우며 session.dirty에서 확인 할 수 있습니다.
True

Session이 flush를 실행하게 되면, 데이터베이스에서 UPDATE가 실행되어 데이터베이스에 실제로 값을 갱신합니다. SELECT 문을 추가로 실행하게 되면, 자동으로 flush가 실행되어 sandy의 바뀐 이름 값을 SELECT를 통해서 바로 얻을 수 있습니다.

>>> sandy_fullname = session.execute(
...     select(User.fullname).where(User.id == 2)
... ).scalar_one()
"""
UPDATE user_account SET fullname=? WHERE user_account.id = ?
[...] ('Sandy Squirrel', 2)
SELECT user_account.fullname
FROM user_account
WHERE user_account.id = ?
[...] (2,)
"""
>>> print(sandy_fullname)
Sandy Squirrel
# flush를 통해 sandy의 변화가 실제로 데이터베이스에 반영되어, dirty 속성을 잃게 됩니다.
>>> sandy in session.dirty 
False

# ORM 사용 업데이트

ORM을 통해 UPDATE 하는 마지막 방법으로 ORM 사용 업데이트를 명시적으로 사용하는 방법이 있습니다. 이를 사용하면 한 번에 많은 행에 영향을 줄 수 있는 일반 SQL UPDATE 문을 사용할 수 있습니다.

>>> session.execute(
...     update(User).
...     where(User.name == "sandy").
...     values(fullname="Sandy Squirrel Extraordinaire")
... )
"""
UPDATE user_account SET fullname=? WHERE user_account.name = ?
[...] ('Sandy Squirrel Extraordinaire', 'sandy')
"""
<sqlalchemy.engine.cursor.CursorResult object ...>

현재 Session에서 주어진 조건과 일치하는 객체가 있다면, 이 객체에도 해당하는 update가 반영되게 됩니다.

>>> sandy.fullname
'Sandy Squirrel Extraordinaire'

# ORM 객체를 삭제하기

Session.delete() 메서드를 사용하여 개별 ORM 객체를 삭제 대상으로 표시할 수 있습니다. delete가 수행되면, 해당 Session에 존재하는 객체들은 expired 상태가 되게 됩니다.

>>> patrick = session.get(User, 3)
"""
SELECT user_account.id AS user_account_id, user_account.name AS user_account_name,
user_account.fullname AS user_account_fullname
FROM user_account
WHERE user_account.id = ?
[...] (3,)
"""
>>> session.delete(patrick) # patrik을 삭제 할 것이라고 명시
>>> session.execute(select(User).where(User.name == "patrick")).first() # 이 시점에서 flush 실행
"""
SELECT address.id AS address_id, address.email_address AS address_email_address,
address.user_id AS address_user_id
FROM address
WHERE ? = address.user_id
[...] (3,)
DELETE FROM user_account WHERE user_account.id = ?
[...] (3,)
SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account
WHERE user_account.name = ?
[...] ('patrick',)
"""
>>> squidward in session # Session에서 만료되면, 해당 객체는 session에서 삭제됩니다.
False

위의 UPDATE에서 사용된 'Sandy'와 마찬가지로, 해당 작업들은 진행중인 트랜잭션에서만 이루어진 일이며 commit 하지 않는 이상, 언제든 취소할 수 있습니다.

# ORM 사용 삭제하기

UPDATE와 마찬가지로 ORM 사용 삭제하기도 있습니다.

# 예시를 위한 작업일 뿐, 실제로 delete에서 필요한 작업은 아닙니다.
>>> squidward = session.get(User, 4)
"""
SELECT user_account.id AS user_account_id, user_account.name AS user_account_name,
user_account.fullname AS user_account_fullname
FROM user_account
WHERE user_account.id = ?
[...] (4,)
"""

>>> session.execute(delete(User).where(User.name == "squidward"))
"""
DELETE FROM user_account WHERE user_account.name = ?
[...] ('squidward',)
<sqlalchemy.engine.cursor.CursorResult object at 0x...>
"""

# Rolling Back

Session에는 현재의 작업들을 롤백하는 Session.rollback() 메소드가 존재합니다. 이 메소드는 위에서 사용된 sandy와 같은 Python 객체에도 영향을 미칩니다. Session.rollback()을 호출하면 트랜잭션을 롤백할 뿐만 아니라 현재 이 Session과 연결된 모든 객체를 expired 상태로 바꿉니다. 이러한 상태 변경은 다음에 객체에 접근 할 때 스스로 새로 고침을 하는 효과가 있고 이러한 프로세스를 지연 로딩 이라고 합니다.

>>> session.rollback()
ROLLBACK

expired 상태의 객체인 sandy 를 자세히 보면, 특별한 SQLAlchemy 관련 상태 객체를 제외하고 다른 정보가 남아 있지 않음을 볼 수 있습니다.

>>> sandy.__dict__
{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x...>}
>>> sandy.fullname # session이 만료되었으므로, 해당 객체 속성에 접근 시, 트랜잭션이 새로 일어납니다.
"""
SELECT user_account.id AS user_account_id, user_account.name AS user_account_name,
user_account.fullname AS user_account_fullname
FROM user_account
WHERE user_account.id = ?
[...] (2,)
"""
'Sandy Cheeks'
>>> sandy.__dict__  #이제 데이터베이스 행이 sandy 객체에도 채워진 것을 볼 수 있습니다.
{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x...>,
 'id': 2, 'name': 'sandy', 'fullname': 'Sandy Cheeks'}

삭제된 객체에 대해서도, Session에 다시 복원되었으며 데이터베이스에도 다시 나타나는 걸 볼 수 있습니다.

>>> patrick in session
True
>>> session.execute(select(User).where(User.name == 'patrick')).scalar_one() is patrick
"""
SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account
WHERE user_account.name = ?
[...] ('patrick',)
"""
True

# Session 종료하기

우리는 컨텍스트 구문 외부에서 Session을 다뤘는데, 이런 경우 다음처럼 명시적으로 Session을 닫아주는 것이 좋습니다.

>>> session.close()
ROLLBACK

마찬가지로 컨텍스트 구문을 통해 생성한 Session을 컨텍스트 구문 내에서 닫으면 다음 작업들이 수행됩니다.

  • 진행 중인 모든 트랜잭션을 취소(예: 롤백)하여 연결 풀에 대한 모든 연결 리소스를 해제합니다.
    • 즉, Session을 사용하여 일부 읽기 전용 작업을 수행한 다음, 닫을 때 트랜잭션이 롤백되었는지 확인하기 위해 Session.rollback()을 명시적으로 호출할 필요가 없습니다. 연결 풀이 이를 처리합니다.
  • Session에서 모든 개체를 삭제합니다.
    • 이것은 sandy, patrick 및 squidward와 같이 이 Session에 대해 로드한 모든 Python 개체가 이제 detached 상태에 있음을 의미합니다. 예를 들어 expired 상태에 있던 객체는 Session.commit() 호출로 인해 현재 행의 상태를 포함하지 않고 새로 고칠 데이터베이스 트랜잭션과 더 이상 연관되지 않습니다.
    • >>> squidward.name
      Traceback (most recent call last):
      ...
      sqlalchemy.orm.exc.DetachedInstanceError: Instance <User at 0x...> is not bound to a Session; attribute refresh operation cannot proceed
      
    • detached된 객체는 Session.add() 메서드를 사용하여 동일한 객체 또는 새 Session과 다시 연결될 수 있습니다. 그러면 특정 데이터베이스 행과의 관계가 다시 설정됩니다.
    • >>> session.add(squidward) # session에 다시 연결
      >>> squidward.name # 트랜잭션을 통해 정보를 다시 불러옵니다.
      """
      SELECT user_account.id AS user_account_id, user_account.name AS user_account_name, user_account.fullname AS user_account_fullname
      FROM user_account
      WHERE user_account.id = ?
      [...] (4,)
      """
      'squidward'
      

detached 상태의 개체는 되도록이면 사용을 지양해야 합니다. Session이 닫히면 이전에 연결된 모든 개체에 대한 참조도 정리합니다. 일반적으로 detached된 객체가 필요한 경우는 웹 어플리케이션에서 방금 커밋된 개체를 뷰에서 렌더링되기 전에 Session이 닫힌 경우가 있습니다. 이 경우 Session.expire_on_commit 플래그를 False로 설정합니다.